From 63291a543aac293d91f2f64faf47bb48886b59c4 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Wed, 14 May 2025 00:13:48 -0400
Subject: When there's no message in view, update last read time based on the
 channel's creation time.

---
 ui/routes/(app)/ch/[channel]/+page.svelte | 15 +++++++--------
 1 file changed, 7 insertions(+), 8 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 33a9bdf..1130cd4 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -10,9 +10,10 @@
   const { session, outbox } = data;
   let activeChannel;
 
-  const channel = $derived(page.params.channel);
-  const messages = $derived(session.messages.filter((message) => message.channel === channel));
-  const unsent = $derived(outbox.messages.filter((message) => message.channel === channel));
+  const channelId = $derived(page.params.channel);
+  const channel = $derived(session.channels.get(channelId));
+  const messages = $derived(session.messages.filter((message) => message.channel === channelId));
+  const unsent = $derived(outbox.messages.filter((message) => message.channel === channelId));
   const deleted = $derived(outbox.deleted.map((message) => message.messageId));
   const unsentSkeletons = $derived(
     unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser)))
@@ -45,10 +46,8 @@
 
   function setLastRead() {
     const lastInView = getLastVisibleMessage();
-    if (lastInView) {
-      const at = DateTime.fromISO(lastInView.dataset.at);
-      session.local.updateLastReadAt(channel, at);
-    }
+    const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel.at;
+    session.local.updateLastReadAt(channelId, at);
   }
 
   $effect(() => {
@@ -76,7 +75,7 @@
   }
 
   async function sendMessage(message) {
-    outbox.postToChannel(channel, message);
+    outbox.postToChannel(channelId, message);
   }
 
   async function deleteMessage(id) {
-- 
cgit v1.2.3


From ae93188f0f4f36086622636ba9ae4810cbd1f8c9 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Wed, 14 May 2025 00:46:13 -0400
Subject: Move derivation of the synthesized view of channels (and messages)
 into `session`.

---
 ui/lib/session.svelte.js                  | 52 +++++++++++++++++++++++++++++--
 ui/lib/state/remote/channels.svelte.js    |  5 ++-
 ui/lib/state/remote/messages.svelte.js    |  7 +----
 ui/routes/(app)/+layout.svelte            | 26 ++--------------
 ui/routes/(app)/ch/[channel]/+page.svelte |  2 +-
 5 files changed, 55 insertions(+), 37 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index 21a391d..2dae3c4 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -6,16 +6,62 @@ import * as api from './apiServer.js';
 import * as r from './state/remote/state.svelte.js';
 import * as l from './state/local/channels.svelte.js';
 import { Watchdog } from './watchdog.js';
+import { DateTime } from 'luxon';
+import { render } from '$lib/markdown.js';
+
+class Channel {
+  static fromRemote({ at, id, name }, messages, meta) {
+    const sentAt = messages
+      .filter((message) => message.channel === id)
+      .map((message) => message.at);
+    const lastEventAt = Math.max(at, ...sentAt);
+    const lastReadAt = meta.get(id)?.lastReadAt;
+
+    const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt;
+    return new Channel({ at, id, name, hasUnreads });
+  }
+
+  constructor({ at, id, name, hasUnreads }) {
+    this.at = at;
+    this.id = id;
+    this.name = name;
+    this.hasUnreads = hasUnreads;
+  }
+}
+
+class Message {
+  static fromRemote({ id, at, channel, sender, body, renderedBody }, users) {
+    return new Message({
+      id,
+      at,
+      channel,
+      sender: users.get(sender),
+      body,
+      renderedBody
+    });
+  }
+
+  constructor({ id, at, channel, sender, body, renderedBody }) {
+    this.id = id;
+    this.at = at;
+    this.channel = channel;
+    this.sender = sender;
+    this.body = body;
+    this.renderedBody = renderedBody;
+  }
+}
 
 class Session {
   remote = $state();
   local = $state();
   currentUser = $derived(this.remote.currentUser);
   users = $derived(this.remote.users.all);
-  channels = $derived(this.remote.channels.all);
   messages = $derived(
-    this.remote.messages.all.map((message) =>
-      message.resolve({ sender: (id) => this.users.get(id) })
+    this.remote.messages.all.map((message) => Message.fromRemote(message, this.users))
+  );
+  channels = $derived(
+    this.remote.channels.all.map((channel) =>
+      Channel.fromRemote(channel, this.messages, this.local.all)
     )
   );
 
diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/channels.svelte.js
index b6da31b..8b190dd 100644
--- a/ui/lib/state/remote/channels.svelte.js
+++ b/ui/lib/state/remote/channels.svelte.js
@@ -1,5 +1,4 @@
 import { DateTime } from 'luxon';
-import { SvelteMap } from 'svelte/reactivity';
 
 class Channel {
   static boot({ at, id, name }) {
@@ -18,10 +17,10 @@ class Channel {
 }
 
 export class Channels {
-  all = $state();
+  all = $state([]);
 
   static boot(channels) {
-    const all = new SvelteMap(channels.map((channel) => [channel.id, Channel.boot(channel)]));
+    const all = channels.map((channel) => Channel.boot(channel));
     return new Channels({ all });
   }
 
diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js
index c6d31f0..0a081bb 100644
--- a/ui/lib/state/remote/messages.svelte.js
+++ b/ui/lib/state/remote/messages.svelte.js
@@ -1,7 +1,7 @@
 import { DateTime } from 'luxon';
 import { render } from '$lib/markdown.js';
 
-export class Message {
+class Message {
   static boot({ id, at, channel, sender, body }) {
     return new Message({
       id,
@@ -21,11 +21,6 @@ export class Message {
     this.body = body;
     this.renderedBody = renderedBody;
   }
-
-  resolve(get) {
-    const { sender, ...rest } = this;
-    return new Message({ sender: get.sender(sender), ...rest });
-  }
 }
 
 export class Messages {
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index 90fa82e..c7e1f22 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -22,29 +22,7 @@
   let pageContext = getContext('page');
   let channel = $derived(page.params.channel);
 
-  let rawChannels = $derived(session.channels);
-  let rawChannelsMeta = $derived(session.local.all);
-  let rawMessages = $derived(session.messages);
-
-  function enrichChannels(channels, channelsMeta, messages) {
-    const enrichedChannels = [];
-    for (const ch of channels.values()) {
-      const createdAt = ch.at;
-      const channelMessages = messages.filter((message) => message.channel === ch.id);
-      const sentAt = channelMessages.map((message) => message.at);
-      const lastEventAt = Math.max(createdAt, ...sentAt);
-
-      const lastReadAt = channelsMeta.get(ch.id)?.lastReadAt;
-      const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt;
-      enrichedChannels.push({
-        ...ch,
-        hasUnreads
-      });
-    }
-    return enrichedChannels;
-  }
-
-  const enrichedChannels = $derived(enrichChannels(rawChannels, rawChannelsMeta, rawMessages));
+  let channels = $derived(session.channels);
 
   function setUpGestures() {
     if (!browser) {
@@ -136,7 +114,7 @@
 
 <div id="interface">
   <nav id="sidebar" data-expanded={pageContext.showMenu}>
-    <ChannelList active={channel} channels={enrichedChannels} />
+    <ChannelList active={channel} {channels} />
     <div class="create-channel">
       <CreateChannelForm {createChannel} />
     </div>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 1130cd4..ea8cca6 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -11,7 +11,7 @@
   let activeChannel;
 
   const channelId = $derived(page.params.channel);
-  const channel = $derived(session.channels.get(channelId));
+  const channel = $derived(session.channels.find((channel) => channel.id === channelId));
   const messages = $derived(session.messages.filter((message) => message.channel === channelId));
   const unsent = $derived(outbox.messages.filter((message) => message.channel === channelId));
   const deleted = $derived(outbox.deleted.map((message) => message.messageId));
-- 
cgit v1.2.3


From a38a449ab78a4e8ab56705922f5c13f9365a92a4 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Wed, 14 May 2025 00:55:34 -0400
Subject: Don't try to update last read time for channel pages if the channel
 has vanished.

This may happen if the user has a link to a channel open when the channel is deleted/expires, or if they return to the app after the last channel they looked at has expired.
---
 ui/routes/(app)/ch/[channel]/+page.svelte | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index ea8cca6..76bb638 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -46,8 +46,10 @@
 
   function setLastRead() {
     const lastInView = getLastVisibleMessage();
-    const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel.at;
-    session.local.updateLastReadAt(channelId, at);
+    const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel?.at;
+    if (!!at) {
+      session.local.updateLastReadAt(channelId, at);
+    }
   }
 
   $effect(() => {
-- 
cgit v1.2.3

